iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

GPU 效能優化實戰:30 天從 Kernel 到 Profiling (重賽版)系列 第 25

Day 25:同一批 Positions 被讀 256 次,Shared Memory 能省多少? [})重賽版({]

  • 分享至 

  • xImage
  •  

https://i.imgur.com/ourg6mL.jpeg
https://i.imgur.com/yC9KuGh.jpeg

嘿嘿嘿
圓規正轉:
一樣AI slob
Day 24 把 force-directed layout 拆成兩種主要工作:

repulsion
    每個 node 對其他所有 nodes
    work = N * N

attraction
    每條 edge 連接兩個 endpoints
    work = E

Naive GPU 版本可以讓一個 thread 負責一個 node:

int i = blockIdx.x * blockDim.x + threadIdx.x;

float px = pos_x[i];
float py = pos_y[i];
float fx = 0.0f;
float fy = 0.0f;

for (int j = 0; j < num_nodes; ++j) {
    float dx = px - pos_x[j];
    float dy = py - pos_y[j];
    float dist_sq = dx * dx + dy * dy;

    if (dist_sq > 1e-8f) {
        float inv = 1.0f / dist_sq;
        fx += inv * dx;
        fy += inv * dy;
    }
}

這已經有平行度,卻留下了一筆很大的記憶體帳。

假設一個 block 有 256 個 threads。它們各自負責不同的 owner node,卻會依序讀取完全相同的 partner positions:

thread 0: pos[0], pos[1], pos[2], ...
thread 1: pos[0], pos[1], pos[2], ...
thread 2: pos[0], pos[1], pos[2], ...
...
thread 255: pos[0], pos[1], pos[2], ...

今天要解的問題不是把 N * N 假裝變成比較低的複雜度。Exact repulsion 的每一對 nodes 都仍然要算。

我們要做的是:

同一批 partner positions
只從 global memory 合作載入一次,
再讓整個 block 重複使用。

這就是 shared-memory tiling。


今天改用 Production FMMM 的 Force Model

Day 24 用 Fruchterman-Reingold,也就是 FR,建立容易理解、可以直接執行的 baseline。原專案的 production layout 則是對齊 OGDF FMMM 的 force model。

兩者都有 attraction、repulsion 與逐輪更新,但公式不完全一樣。今天分析的 CUDA kernel 使用:

repulsion from j to i:

dx = x[i] - x[j]
dy = y[i] - y[j]
dist_sq = dx * dx + dy * dy

F_rep_x[i] += dx / dist_sq
F_rep_y[i] += dy / dist_sq

edge (u, v) 的 attraction 使用 edge 的理想長度 L

dx = x[v] - x[u]
dy = y[v] - y[u]
d  = sqrt(dx * dx + dy * dy)

scalar = log2(d / L) * d / (L * L * L)

F_attr_x[u] += scalar * dx
F_attr_y[u] += scalar * dy

最後把兩種力合併,並限制單輪移動距離:

force = (spring_strength * attraction
       + repulsion_strength * repulsion) * avg_L * avg_L

norm = length(force)
step = min(norm * force_scaling_factor, max_radius) / norm

displacement = step * force

這裡刻意保留公式差異。GPU 最佳化可以搬用相同的 tiling、memory placement 與 kernel fusion 思路,但不能在換了 force model 後,仍宣稱和 Day 24 的 FR 是逐值相同的演算法。


先算一筆 Global Memory 的帳

一個 position 有 xy 兩個 float

bytes_per_position = 2 * 4 = 8 bytes

現在取 256 個 partner nodes 當成一個 tile,並讓一個 256-thread block 計算 256 個 owners。

Naive 寫法中,每個 owner 都要請求整個 tile:

naive requested bytes
    = 256 owners * 256 partners * 8 bytes
    = 524,288 bytes
    = 512 KiB

Tiled 寫法讓 256 個 threads 合作載入:

tiled requested global bytes
    = 256 partners * 8 bytes
    = 2,048 bytes
    = 2 KiB

從程式發出的 global-load 請求來看,同一個 block 對這批 partner positions 的重複讀取最多可以從 256 份降成 1 份:

request reduction = 512 KiB / 2 KiB = 256x

但這不是 kernel 的 256 倍 speedup。

原因包括:

GPU cache 可能已經吸收一部分重複讀取
每一對 nodes 的算術仍然存在
shared memory 也有 load 指令
每個 tile 需要 synchronization
register 與 shared-memory 用量會影響 occupancy
attraction、更新座標與 launch overhead 沒有一起縮小 256 倍

所以這個 256x 只能叫做「特定 tile 內的理論 global-load request reduction」。它不能被寫成 benchmark 結果。


Tiled Kernel 怎麼走?

原專案把 tile size 與 block size 都設成 256:

#define FMMM_TILE_K 256
#define FMMM_TILE_BLOCK 256

一輪 repulsion 的資料流如下:

global pos_x / pos_y
          |
          | 256 threads cooperative load
          v
+-----------------------------+
| shared s_tile[0 ... 255]     |
+-----------------------------+
     |       |       |
     v       v       v
 owner 0  owner 1  owner 2  ...
 register register register
     |       |       |
     +--- accumulate forces ---+

核心結構是:

__shared__ float2 s_tile[FMMM_TILE_K];

for (int base = 0; base < num_nodes; base += FMMM_TILE_K) {
    int nload = min(FMMM_TILE_K, num_nodes - base);

    for (int k = threadIdx.x; k < nload; k += blockDim.x) {
        s_tile[k] = make_float2(
            pos_x[base + k],
            pos_y[base + k]
        );
    }
    __syncthreads();

    accumulate_my_owners(s_tile, nload);

    __syncthreads();
}

第一個 __syncthreads() 的意思是:

所有人都放完資料後,任何人才能開始讀 tile

第二個 __syncthreads() 的意思是:

所有人都用完目前的 tile 後,才可以覆寫它

少了第一個 barrier,thread 可能讀到尚未載入的欄位。少了第二個 barrier,速度較快的 thread 可能先把下一個 tile 寫進來,破壞其他 thread 還在使用的資料。

這兩個 barrier 都是 correctness 的一部分。


為什麼 Global Memory 用 SoA,Shared Memory 卻用 float2

Global memory 裡的座標分成兩條陣列:

pos_x = [x0, x1, x2, x3, ...]
pos_y = [y0, y1, y2, y3, ...]

這叫 Structure of Arrays,縮寫 SoA。

當相鄰 threads 載入相鄰索引時:

thread 0 -> pos_x[base + 0]
thread 1 -> pos_x[base + 1]
thread 2 -> pos_x[base + 2]
...

這些位址是連續的,GPU 可以把它們合併成較少的 memory transactions,也就是 coalesced access。pos_y 也是一樣。

載入 shared memory 後,kernel 把同一個 node 的 xy 組成 float2

s_tile[k] = { pos_x[base + k], pos_y[base + k] }

repulsion 每次一定同時使用 partner 的 xy。shared memory 使用 float2,讓程式更自然地取出一個完整 position。

這是一個混合配置:

global memory
    SoA: float pos_x[], float pos_y[]
    目的:連續、容易 coalesce

shared memory
    AoS-like: float2 s_tile[]
    目的:一起消費同一個 node 的 x/y

SoA 或 AoS 沒有永遠正確的答案。要看資料在那一層由哪些 threads 載入,以及計算時會一起使用哪些欄位。


Shared Memory 放的是「別人的座標」

每個 owner 的資料不需要放進 shared memory。

在 tile loop 期間,owner 的座標與累加結果會被反覆使用:

owner position: qx, qy
repulsion accumulator: frx, fry
previous displacement: lx, ly

它們最適合留在 thread-private registers:

float qx[FMMM_TILE_OWN];
float qy[FMMM_TILE_OWN];
float frx[FMMM_TILE_OWN];
float fry[FMMM_TILE_OWN];
float lx[FMMM_TILE_OWN];
float ly[FMMM_TILE_OWN];

完整的資料放置可以畫成:

registers, private to one thread
    owner indices
    owner positions
    accumulated repulsion
    previous displacement

shared memory, shared by one block
    current remote position tile

global memory, visible to the grid
    all positions
    CSR adjacency
    displacement arrays
    iteration outputs

判斷資料該放哪裡時,可以問:

只有一個 thread 高頻使用?
    -> register

同一個 block 的很多 threads 會重用?
    -> shared memory

資料太大,或需要跨 blocks 共享?
    -> global memory

把所有資料都塞進 shared memory 並不會自動變快。CSR adjacency 大小隨 E 增長,而且每個 owner 只讀自己的鄰居範圍;把整份 CSR 搬進 shared memory,容量不實際,重用率也不高。


一個 Thread 為什麼要擁有 8 個 Nodes?

Production tile kernel 設定:

#define FMMM_TILE_OWN 8

每個 thread 用 grid-stride 的方式,最多接 8 個 owner nodes:

for (int i = tid;
     i < num_nodes && nown < FMMM_TILE_OWN;
     i += nthreads) {

    idx[nown] = i;
    qx[nown] = pos_x[i];
    qy[nown] = pos_y[i];
    ++nown;
}

這看起來有點反直覺。GPU 不是應該讓 thread 越多越好嗎?

關鍵在 tile 的取得成本。

如果一個 256-thread block 每個 thread 只處理一個 owner,一次 2 KiB 的 tile load 服務 256 個 owners。若每個 thread 擁有 8 個 owners,同一次 tile load 最多服務:

256 threads * 8 owners = 2,048 owners

也就是把 cooperative load 和兩次 barrier 的成本,攤在更多 pair calculations 上。

內層還一次展開四個 partners:

repel(owner, tile[k + 0]);
repel(owner, tile[k + 1]);
repel(owner, tile[k + 2]);
repel(owner, tile[k + 3]);

這讓編譯器更容易交錯獨立指令,隱藏 reciprocal 與 arithmetic latency。

代價是每個 owner都需要自己的 qxqyfrxfrylxly。owner 數增加,register pressure 也增加。

如果 compiler 無法把它們都留在 registers,就可能 spill 到 local memory。CUDA 的 local memory 名稱聽起來很近,實際上通常位於 device memory,延遲可能接近 global memory。

所以 OWN = 8 是工程上的 trade-off:

OWN 太小
    tile reuse 不足
    barrier 成本分攤得不夠

OWN 太大
    registers per thread 增加
    active warps 可能減少
    甚至出現 local-memory spill

最佳值不能只靠直覺。要用實際編譯器 register report 與 Nsight Compute 的 occupancy、local load/store 指標確認。


Attraction 不再用 Edge Threads 搶同一個 Endpoint

Naive attraction 很自然地讓一個 thread 處理一條 edge:

thread e = (u, v)
    compute force
    atomicAdd(force[u], +f)
    atomicAdd(force[v], -f)

問題是 high-degree node 會讓很多 edge threads 同時更新同一個 endpoint:

       e1
        \
e2 ------ hub ------ e3
        /
       e4

many threads -> atomicAdd(force[hub])

Tile kernel 已經讓一個 thread 擁有 node,因此 attraction 也改成 node-owned traversal。

Graph 先轉成 CSR:

row_ptr[i] .. row_ptr[i + 1]
    -> node i 的所有 neighbor IDs
    -> 每條 adjacency 的 desired length

owner thread 只累加自己的 attraction:

float f_attr_x = 0.0f;
float f_attr_y = 0.0f;

for (int e = row_ptr[i]; e < row_ptr[i + 1]; ++e) {
    int v = nbr_j[e];
    float L = nbr_L[e];

    // calculate contribution to owner i
    f_attr_x += ...;
    f_attr_y += ...;
}

因為 accumulator 是 thread-private,這裡不需要 atomicAdd

代價是 undirected edge 會以兩個 adjacency entries 儲存:

u -> v
v -> u

兩個 endpoints 各自計算自己的 contribution。多一份 adjacency 儲存與讀取,換掉了 atomic contention,也讓結果不再依賴多個 edge threads 的 atomic 執行順序。

這個改動展示一個很實用的 GPU 原則:

改變工作擁有權,
有時比加速 atomic 更有效。

為什麼不能算完 Force 就立刻更新 Position?

每一輪的所有 forces 必須以同一份 current positions 為輸入:

positions at iteration t
          |
          v
calculate every displacement
          |
       grid sync
          |
          v
update every position
          |
       grid sync
          |
          v
positions at iteration t + 1

如果 block 0 算完便先更新座標,而 block 1 還在讀舊座標,同一輪就會混用兩個時間點:

some partners from iteration t
some partners from iteration t + 1

這會把同步 Jacobi-style update 變成與排程順序有關的非同步更新,演算法本身已經改變。

__syncthreads() 只能同步一個 block。Tile kernel 使用 cooperative groups 的:

cg::grid_group grid = cg::this_grid();

grid.sync();

讓整個 grid 在「寫 displacement」與「更新 positions」之間同步。

這也是 cooperative kernel 的限制:所有 blocks 必須能同時 resident。Host 端先查:

cudaOccupancyMaxActiveBlocksPerMultiprocessor(...)

再用:

resident block capacity
    = SM count * active blocks per SM

限制 launch 的 block 數量。

這裡的 occupancy 不只是效能數字。它直接決定有多少 cooperative blocks 可以安全參與 grid-wide barrier。

同時,OWN = 8 也有 coverage 限制:

covered nodes <= launched_blocks * 256 threads * 8 owners

因此 backend support check 不能只看 NE 的硬上限;實務上也要測試裝置是否支援 cooperative launch,以及 launch configuration 是否真的覆蓋每個 node。這是把漂亮 kernel 變成可靠 production backend 時很容易漏掉的邊界。

而且這不是假設性的提醒。目前快照中的 tile_force_supported(...) 只檢查:

0 < N <= 200,000
0 <= E <= 2,000,000

如果 cooperative launch 失敗,host code 會改用一個 block 啟動相同 kernel。一個 block 配上 OWN = 8 最多只覆蓋:

1 block * 256 threads * 8 owners = 2,048 nodes

所以 N > 2,048 時,這個 fallback 不能只靠「kernel 有成功 launch」就視為正確。較完整的處理方式應該是:

cooperative launch unavailable
    -> select a backend that does not require grid.sync()
    -> or split iterations into ordinary kernels at a global barrier

never
    -> silently reduce to one block when one block cannot cover N

這也是測試不能只跑主力 GPU 路徑的原因。必須刻意模擬 cooperative launch 不可用,才能驗證 fallback 仍處理全部 nodes。


把多輪 Iterations 融合進同一個 Kernel

最直觀的 host-driven loop 會像這樣:

for each iteration:
    launch repulsion kernel
    launch attraction kernel
    launch combine kernel
    launch update kernel
    synchronize
    possibly read convergence data on CPU

Force 計算本身很大時,launch overhead 可能不是主角;但在 multilevel FMMM 的小圖層、很多 seeds 或短 iteration 中,頻繁 launch 與 host synchronization 會變得明顯。

Production path 把 iteration loop 放進 kernel:

for (int it = 1; it <= max_iters; ++it) {
    load tiles and compute repulsion;
    traverse CSR and compute attraction;
    write displacement;
    grid.sync();

    update positions;
    grid.sync();
}

一次呼叫只做:

H2D positions once
H2D topology when needed
one resident multi-iteration kernel
D2H final positions once

其中 positions 仍會在每輪從 global memory 載入 tile;它們不是永遠留在 shared memory。真正省下的是多次 kernel launch、host control,以及每輪返回 Python 的機會。

另一個需要明講的差異是 convergence。Flash kernel 每輪計算平均 displacement,可以在低於 threshold 時提前停止;目前的 tile kernel 雖然從 Python 收到 threshold,device kernel 並沒有使用它,而是固定執行 max_iters

flash path
    stop when average displacement < threshold

current tile path
    always run max_iters

固定 iteration count 讓 resident loop 與跨 block 同步更單純,代價是已經收斂的 layout 仍可能多做工作。Benchmark 若只寫「相同 threshold」,兩個 backend 其實沒有相同的停止條件;公平比較應同時報告實際執行輪數。

專案還用可成長的 workspace 保存 device buffers:

capacity enough
    -> reuse existing cudaMalloc buffers

capacity insufficient
    -> grow capacity and allocate again

這避免在每一輪或每一個相近大小的呼叫中反覆 cudaMalloc / cudaFree


小圖為什麼有另一個 Flash Backend?

若 graph 足夠小,全部 positions 可以直接留在一個 block 的 shared memory:

project limits
    N <= 768
    E <= 4096

Flash kernel 使用一個 block 處理一份 layout,shared state 包括:

s_pos[768]
s_disp[768]
s_last[768]
reduction scratch

它的資料流變成:

global positions
      |
      | load once
      v
shared positions
      |
      | many force iterations
      v
global final positions

這比大型 tiled path 更徹底地減少 position round trips,也只需要 block-level synchronization。

可是單一小圖只啟動一個 block,GPU 上其他 SM 可能全部閒著。要提高整張 GPU 的 throughput,較好的用法是一次處理多份 seeds:

block 0 -> layout seed 0
block 1 -> layout seed 1
block 2 -> layout seed 2
...

這是另一個反直覺結果:

單一 layout 的 latency 已經很低,
GPU utilization 卻可能很差。

增加 batch,
單一 layout 未必更快,
整體 layouts / second 會提高。

因此 production code 需要依 graph size 分 backend:

small graph
    -> flash: whole position state in one block

larger exact graph
    -> tile: shared position tiles across all resident blocks

very large graph or unsuitable device
    -> Barnes-Hut or another fallback

Barnes-Hut 用空間樹近似遠方 node groups,把 exact all-pairs 的工作量往較低方向壓。但它需要建樹與不規則 traversal。對中小型 graph,exact tiled kernel 可能因為資料流規律而比較快;N 很大時,N * N 的算術量才會迫使我們改用近似法。


三個「看起來更快」卻可能變慢的地方

第一個是 tile 變大。

larger tile
    -> more reuse per load
    -> more shared memory per block
    -> possibly fewer resident blocks

第二個是每個 thread 擁有更多 nodes。

more owners
    -> amortize tile load and barriers
    -> more registers
    -> possibly lower occupancy or spills

第三個是融合更多 iterations。

more fusion
    -> fewer launches and host round trips
    -> longer kernel lifetime
    -> more state stays live
    -> less flexibility for convergence checks and fallback

所以調校時不能只問:

shared memory 有沒有用?

應該同時量:

kernel duration
DRAM bytes read/write
shared-memory throughput
registers per thread
local load/store, to detect spills
achieved occupancy
eligible warps per cycle
barrier stall reasons

Shared memory 是用容量與同步換取重用。只看「用了 shared memory」這件事,無法判斷最佳化是否成功。


Correctness:先比 Force,再比最終的 K

GPU force kernel 使用 float32,CPU reference 使用 float64。加法順序、reciprocal 指令與平行執行都可能產生小幅差異,因此測試不應要求 bitwise equality。

原專案的 unit tests 使用:

np.testing.assert_allclose(
    gpu_force,
    cpu_reference,
    rtol=1e-4,
    atol=1e-5,
)

驗證順序應該分層:

1. formula parity
   GPU repulsion / attraction 對 CPU double reference

2. iteration invariants
   所有 nodes 有被處理
   沒有 NaN / Inf
   zero distance 不會除以零
   displacement 不超過 max_radius
   cooperative fallback 仍覆蓋最後一個 node

3. backend differential test
   exact、flash、tile 在小圖上輸出接近

4. LCN admission
   quantize to integer grid
   repair duplicate / node-on-edge / overlapping-edge violations
   official validator passes

5. downstream quality
   exact Crossing evaluator 計算 initial K
   固定 SA budget 後再比較 final K

這裡不能只檢查 force energy 是否下降。Layout 的 force objective 和 LCN 的 crossing count K 不是同一個目標。

force converged
    does not imply
minimum crossing count

效能數字要怎麼量才不會誤導?

專案附有 benchmark_fmmm_cuda.py,會分別輸出:

GPU total time
coarsening time
force compute time
CPU NetworkX spring_layout time

但目前快照沒有保存同一張 GPU 上、同一個 commit、同一組參數的 tiled-vs-naive 原始結果,因此這篇不虛構一個 speedup 數字。

正式比較至少要固定:

GPU model and clock policy
CUDA build flags
graph topology and seed
force formula
iteration count or convergence threshold
warm-up policy
whether H2D / D2H is included
whether coarsening and quantization are included

並同時報兩種範圍:

kernel-only time
    回答 tiled force kernel 本身快多少

end-to-end layout time
    包含 topology preparation、copies、coarsening、quantization
    回答使用者實際等多久

如果拿 NetworkX FR 當 CPU 對照,還要明確寫出兩邊不是完全相同的 force model。這種結果能比較「兩個 layout providers 的耗時與下游品質」,不能歸因成單一 CUDA optimization 的純 speedup。

今天唯一可以不靠 GPU 實測而精確回答的,是特定 mapping 下的 load-request 模型:

tile positions = 256
position bytes = 8

global bytes per block per tile
    naive, 1 owner/thread = 512 KiB requested
    tiled                  =   2 KiB requested

theoretical request reduction = 256x

實際 DRAM traffic 與 runtime 必須由 profiler 回答。


這個案例介紹了 GPU 最佳化的哪一部分?

Day 23 的核心是演算法增量化:移動一個 node,只重算可能改變的 crossing pairs。

Day 25 的 exact repulsion 無法刪掉 node pairs,因此它展示的是另一條路:

work cannot be removed
    -> reduce data movement
    -> increase reuse
    -> keep accumulators close to compute
    -> fuse synchronization boundaries

具體來說:

  1. 用 coalesced loads 把連續 positions 搬進 shared memory。
  2. 讓整個 block 重用同一個 position tile。
  3. 把 owner state 與 force accumulators 留在 registers。
  4. 讓一個 thread 處理多個 owners,攤平 load 與 barrier 成本。
  5. 把 edge-owned atomic accumulation 改成 node-owned CSR traversal。
  6. 把多輪 force iterations 留在一個 resident kernel。
  7. 依 graph size 選 flash、tile 或近似 backend。
  8. 用 occupancy、spill 與 DRAM traffic 驗證最佳化,而不是看到 shared memory 就宣布成功。

這也是選 Layout 當 GPU 案例的原因。它很清楚地表現出 GPU 效能不只來自更多 threads;真正的差異常常來自同一筆資料被搬了幾次,以及中間狀態能否留在離運算單元更近的位置。

Day 26 會進入 simulated annealing。Layout 產生一個起點後,SA 會直接針對 LCN objective 提出 move、計算 delta、決定接受或拒絕。第一步先從單條、容易驗證的 Naive SA chain 開始。

程式對照


上一篇
Day 24:Crossing Evaluator 會打分數,但第一張圖從哪裡來?}}重賽版{{
下一篇
Day 26:明明找到更好的 Layout,為什麼 SA 還要故意走回頭路?({]重賽版[})
系列文
GPU 效能優化實戰:30 天從 Kernel 到 Profiling (重賽版)26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言